AWS CDK high-level construct で AWS AppSync を構築する
アプリケーションのバックエンドと通信する手段の一つに GraphQL があります。AWS AppSync は、フルマネージドの GraphQL サービスです。Apollo Clientをはじめ GraphQL まわりのエコシステムが充実し、特にフロントエンドからみた Backend for Frontend としての役割は実運用も視野に入ります。また少し前に、 AppSync は AWS CDK の high-level construct に対応しました。本稿では AWS CDK high-level construct を使ってデータソースからデータを取得する例を示します。
AWS CDK high-level construct の良さ
high-level construct がない場合、 AWS CDK で AppSync のリソースを作ろうと思ったら、CloudFormation のプロパティ名と同じものを指定してリソースを作っていく必要があります。つまり、データソースと連携するIAMロール、そのポリシーは自前で定義することになります。CDK の良さは、定義したリソースを抽象的に扱い相互連携・権限付与・要素定義を行える点にあります。裏側にある DynamoDB や Lambda Function と連携することが前提になる AppSync にとって、このメリットは大きいです。
やる内容
データソースと連携する
いくつかのデータソースと連携する例を示します。
- Lambda Function データソース (Direct Lambda Resolvers)
- DynamoDB データソース
環境
- 利用言語: TypeScript
利用ツール | バージョン |
---|---|
aws-cdk | 1.69.0 |
なお本稿の内容は、GitHub リポジトリ で確認できます。
AppSync を high-level construct で定義する
AWS CDK のコードを書いていきましょう。関数スタイルで書くと async/await にも対応できおすすめです。
Lambda Function データソース
まずは、 Direct Lambda Resolvers でつながった Lambda Function です。基本的に、AWS AppSync で GraphQL API をつくり、Lambda Function を呼び出すためには、入出力を変換する Velocity Template(以下、VTL) を挟む必要があります。しかし機能アップデートにより Lambda Function に限り、VTLを省略できるオプションが手に入りました。別途記事をおこしていますのでご参照ください。
以下、そんな VTL なしのシンプルな GraphQL 定義です。Lambda Function とつなげます。
import * as cdk from '@aws-cdk/core'; import { Stack } from '@aws-cdk/core'; import * as lambda from '@aws-cdk/aws-lambda'; import * as appsync from '@aws-cdk/aws-appsync'; import { AuthorizationType, FieldLogLevel } from '@aws-cdk/aws-appsync'; import { GlobalProps, NODE_LAMBDA_LAYER_DIR, NODE_LAMBDA_SRC_DIR, } from './global-props'; import * as path from 'path'; export async function greetingServiceApplicationStack( scope: cdk.Construct, id: string, global: GlobalProps, ): Promise<Stack> { const stack = new cdk.Stack(scope, id, { stackName: global.getStackName(id), }); // node_modules LayerVersion const nodeModulesLayer = new lambda.LayerVersion( stack, 'NodeModulesLayer', { layerVersionName: 'NodeModulesLayer', code: lambda.Code.fromAsset(NODE_LAMBDA_LAYER_DIR), description: 'Node.js modules layer', compatibleRuntimes: [lambda.Runtime.NODEJS_12_X], }, ); const greetingFn = new lambda.Function(stack, 'GetGreetingReply', { functionName: global.getFunctionName('GetGreetingReply'), code: lambda.Code.fromAsset(NODE_LAMBDA_SRC_DIR), handler: 'lambda/handlers/appsync/greeting/get-greeting-reply-handler.handler', runtime: lambda.Runtime.NODEJS_12_X, layers: [nodeModulesLayer], environment: { REGION: cdk.Stack.of(stack).region, }, tracing: Tracing.ACTIVE, }); const graphApi = new appsync.GraphqlApi(stack, 'GreetingBff', { name: global.getGraphApiName('GreetingBff'), logConfig: { excludeVerboseContent: true, fieldLogLevel: FieldLogLevel.ALL, }, authorizationConfig: { defaultAuthorization: { authorizationType: AuthorizationType.API_KEY, }, additionalAuthorizationModes: [], }, schema: appsync.Schema.fromAsset( path.join(__dirname, 'schema.graphql'), ), xrayEnabled: true, }); // Lambda Function Datasource const greetingFnDataSource = graphApi.addLambdaDataSource( 'GreetingFnDataSource', greetingFn, ); /** * Lambda Direct Resolvers */ greetingFnDataSource.createResolver({ typeName: 'Query', fieldName: 'getReply', }); return stack; }
分解してみていきます。
Lambda Function の定義
// node_modules LayerVersion const nodeModulesLayer = new lambda.LayerVersion( stack, 'NodeModulesLayer', { layerVersionName: 'NodeModulesLayer', code: lambda.Code.fromAsset(NODE_LAMBDA_LAYER_DIR), description: 'Node.js modules layer', compatibleRuntimes: [lambda.Runtime.NODEJS_12_X], }, ); const greetingFn = new lambda.Function(stack, 'GetGreetingReply', { functionName: global.getFunctionName('GetGreetingReply'), code: lambda.Code.fromAsset(NODE_LAMBDA_SRC_DIR), handler: 'lambda/handlers/appsync/greeting/get-greeting-reply-handler.handler', runtime: lambda.Runtime.NODEJS_12_X, layers: [nodeModulesLayer], environment: { REGION: cdk.Stack.of(stack).region, }, tracing: Tracing.ACTIVE, });
この部分で Lambda Function を定義しています。これは つなぎ先が AppSync だろうが API Gateway だろうが変わらない書き方です。なお LayerVersion
ですが、node_modules
を詰めてLayerVersion
に上げ、それを Lambda Function から参照するということをやっています。
AppSync の定義
次に AppSync 本体です。必須ではないプロパティもあえて指定しています。
const graphApi = new appsync.GraphqlApi(stack, 'GreetingBff', { name: global.getGraphApiName('GreetingBff'), logConfig: { excludeVerboseContent: true, fieldLogLevel: FieldLogLevel.ALL, }, authorizationConfig: { defaultAuthorization: { authorizationType: AuthorizationType.API_KEY, }, additionalAuthorizationModes: [], }, schema: appsync.Schema.fromAsset( path.join(__dirname, 'schema.graphql'), ), xrayEnabled: true, });
logConfig
logConfig
は、CloudWatch Logs に出力するログの充実度合いです。 excludeVerboseContent
を false
にするとリクエストヘッダなどの付随情報が出力されます。
excluedVerboseContent
による違い
false
、 つまり verboseContent も出力するとした場合、以下のようなログも CloudWatch Logs へ出力されます。
Request Headers: {content-length=[1550], referer=[https://ap-northeast-1.console.aws.amazon.com/], cloudfront-viewer-country=[JP], sec-fetch-site=[cross-site], origin=[https://ap-northeast-1.console.aws.amazon.com], x-forwarded-port=[443], x-amz-user-agent=[AWS-Console-AppSync/], via=[2.0 cloudfront.net (CloudFront)], cloudfront-is-desktop-viewer=[true], host=[appsync-api.ap-northeast-1.amazonaws.com], content-type=[application/json], sec-fetch-mode=[cors], x-forwarded-proto=[https], accept-language=[en-US,en;q=0.9,ja;q=0.8,jv;q=0.7], x-forwarded-for=[], accept=[*/*], cloudfront-is-smarttv-viewer=[false], x-amzn-trace-id=[Root=1-5f8fa85e-68fd6851492868c8099568d6], x-api-key=[****], cloudfront-is-tablet-viewer=[false], cloudfront-forwarded-proto=[https], x-amz-cf-id=[B2UrwjKLWVeX5l5EL5NkxPdvaRc0-hfRcwwdCIYNBekQU-BUtAKWdw==], accept-encoding=[gzip, deflate, br], user-agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.80 Safari/537.36], sec-fetch-dest=[empty], cloudfront-is-mobile-viewer=[false]}
fieldLogLevel
による違い
NONE
の場合、RequestSummary
, RequestSummary
というログタイプのものだけが CloudWatch Logs へ出力されるようです。
'ALL' の場合、マッピングテンプレートによる変換ログなども出力するようです。
なおどちらの場合も Request Headers のログは出力していますね。excluedVerboseContent
の設定は fieldLogLevel
によらないことがわかります。
authorizationConfig
authorizationConfig
は、このAppSyncにかける認証設定です。API Key, Cognito User Pool, OpenID Connect, IAM から選ぶことができます。
schema
schema
はこのGraphQLのスキーマを定義します。大抵の場合はファイルとして切り出して管理するほうが無難です。よってここでも appsync.Schema.fromAsset
でスキーマファイルを指定する方法をとっています。スキーマファイルのサンプルを載せます:
input Message { message: String! } type MessageTemplate { id: String! message: String! } type Query { getReply(input: Message): Reply! } type Mutation { createTemplate(input: Message): MessageTemplate! } type Reply { reply: String! }
xrayEnabled
true
にすると、AWS X-Ray によるトレーシングが有効になります。エラー率やパフォーマンスの確認に役立ちます。特に AppSync のようにバックエンドと多くつながるサービスにとっては、串刺しでモニタリングできるのは嬉しいと思いました。
データソースとリゾルバの設定
Lambda Function と AppSync の定義がおわったので、それらをつないでいきます。GraphQL の世界では、GraphQL定義(スキーマ)とつながるバックエンドのことを「データソース」、スキーマに対する具体的なつなぎ方を示した操作内容のことを「リゾルバ」といいます。これを踏襲し、AppSyncの世界でもデータソースとそのリゾルバを設定する必要があります。それが以下のコードです。
// Lambda Function Datasource const greetingFnDataSource = graphApi.addLambdaDataSource( 'GreetingFnDataSource', greetingFn, ); /** * Lambda Direct Resolvers */ greetingFnDataSource.createResolver({ typeName: 'Query', fieldName: 'getReply', });
addLambdaDataSource
でデータソースを、 createResolver
でリゾルバを定義しています。addLambdaDataSource
は さきほど定義した graphApi
のメソッドであり、createResolver
は データソースから生えたメソッドであることがわかります。このようにリソースの従属関係がコードで表現できるのは、AWS CDK の強みの一つです。CloudFormation だと、たいていYAMLのインデントで従属関係が表現されるわけですが、どのリソースに何がぶら下がるかはドキュメントとにらめっこしないとわかりません。AWS CDK TypeScript の場合はそのあたりが型情報で表現できるため、リファレンスを見る頻度がぐっと減ったと実感します。
DynamoDB データソース
ではマッピングテンプレートが必要なタイプのデータソースを定義してみましょう。例えばメッセージのテンプレートを保存する DynamoDB を定義します。
/** * Greeting Template DynamoDB */ const templateTable = new dynamo.Table(stack, 'TemplateTable', { tableName: global.getTableName('Template'), partitionKey: { name: 'id', type: AttributeType.STRING }, }); // DynamoDB DataSource const ddbSource = graphApi.addDynamoDbDataSource( 'TemplateTableDataSource', templateTable, ); ddbSource.createResolver({ typeName: 'Mutation', fieldName: 'createTemplate', requestMappingTemplate: MappingTemplate.dynamoDbPutItem( PrimaryKey.partition('id').auto(), Values.projecting('input'), ), responseMappingTemplate: MappingTemplate.dynamoDbResultItem(), });
リゾルバを定義している部分に注目してください。マッピングテンプレートを指定している風ですが、実体であるVTLはここでは一行も書いていません。そうです、MappingTemplate.dynamoDbPutItem
と MappingTemplate.dynamoDbResultItem
は、「DynamoDBとのやりとりってだいたいこういうVTLでしょ」ということで AWS CDK が用意してくれたものです。これは大変ありがたいですね。PutItem
時には PrimaryKey.partition('id').auto()
とすることで uuid を自動生成する指定もできます。 high-level construct の強みがここでも発揮されています。ちなみにこれで定義したクエリは以下のように投げることで DynamoDB に保存できます。
おわりに
AWS CDK high-level construct を使って AWS AppSync を構築すると、次のようなメリットが得られることを述べました:
- CloudFormation Template のようにすべてを定義として書くのではなく、AWS CDK で用意されているメソッドを使いベースリソースを拡張する形でかける
- Direct Lambda Resolver で VTL の利用を回避できる
MappingTemplate
の利用で VTL のメンテナンスを回避できる
high-level construct は開発途上で、issue でトラッキングできます。これからも随時チェックして便利に使えそうになったら試してきます。
Tracking: AWS AppSync · Issue #6836 · aws/aws-cdk